React 원리 : FiberNode와 VDOM으로 알아보는 React Rendering

리액트가 어떻게 렌더링되고 어떻게 부수효과가 실행되는지를 알려면 FiberNode와 VDOM에 대해서 알아야한다. FiberNode 구조와 useState, useEffect가 어떻게 동작하는지 알아보자

사전지식 1 : 더블버퍼링 구조의 VDOM

실제 DOM 조작은 비용이 크다. 브라우저가 레이아웃 계산(Layout) → 페인트(Paint) → 합성(Composite) 순서로 화면을 다시 그려야 하기 때문이다.

이 과정은 브라우저의 Render Process의 Main Thread를 점유한다. 즉, 자주 DOM 조작하면 저 무거운 작업을 하느라 JS가 멈춰 유저는 렉처럼 볼 수도 있다.

React는 실제 DOM 조작이 비용이 크다는 점을 줄이기 위해, 메모리에 올려진 JavaScript 객체 트리, 즉 Virtual DOM(VDOM)을 먼저 조작한 후 진짜 변경될 내용을 batch로 처리하여 한 큐에 DOM을 변경한다.

더 나은 이해를 위해 VDOM에 대해 하나만 더 짚고가자

VDOM은 2개다 (Current VDOM, WorkInProgress VDOM)

VDOM은 FiberNode의 트리 라는 것과
모든 컴포넌트는 자신의 FiberNode를 가지고 있다는 것을 먼저 알아야한다.

그리고 React는 실제 DOM과 똑같은 구조의 Current Virtual DOM
상태변경을 통해 업데이트 중인 WorkInProgress Virtual DOM 두 가지를 유지한다.

Diff를 위한 alternate 프로퍼티

FiberNode의 alternate라는 프로퍼티를 통해, 한 컴포넌트의 Current FiberNodeWorkInProgress FibeNode가 서로 연결되어 있다.

interface FiberNode {
	// 컴포넌트에 대한 정보
	tag: WorkTag; // FunctionComponent, ClassComponent, HostComponent 등
	type: any; // 컴포넌트 함수 자체 (예: App)
	key: null | string; // reconciliation용 key
	elementType: any; // JSX element type

	// VDOM 트리상 관계 (부모/자식/형제)
	return: FiberNode | null; // 부모
	child: FiberNode | null; // 첫 번째 자식
	sibling: FiberNode | null; // 형제

	// 렌더링 관련 상태
	pendingProps: any; // 아직 적용되지 않은 props
	memoizedProps: any; // 이전 렌더에서 사용된 props
	**memoizedState**: Hook | null; // Hook들의 연결 리스트 진입점
	flags: Flags; // commit 단계에서 수행할 작업들 (Placement, Update 등)

	// 스케줄링 및 성능 관리
	lanes: Lanes; // 업데이트 우선순위
	alternate: FiberNode | null; // current <-> workInProgress 쌍
}

React는 이 연결을 따라 두 트리를 비교(Diff)하고, 실제 DOM에 변경이 필요한 부분만 효율적으로 적용한다. WorkinProgress가 Current가 되면 다시 WorkinProgress를 만들어 다음 작업에 활용하게 한다. 마치 배포 시 Blue/Green 전략처럼, React는 현재 화면(Current)과 새 화면(WorkInProgress)을 동시에 준비한 뒤, 안전하게 변경 사항만 적용한다.


사전지식 2 : 렌더링 정보를 담고 있는 FiberNode

일반적인 렌더링과정을 통해 FiberNode에 대해 알아보자

  1. <Component/>는 Babel을 거쳐 React.createElement() 호출로 변환된다.
  2. 런타임에 React는 최상위부터 시작해, 모든 컴포넌트를 재귀적으로 호출하며 렌더링을 진행한다.
  3. React.createElement()가 반환하는 것은 일반 JavaScript 객체인 ReactElement다.
  4. React는 이 객체를 확장하여 FiberNode를 생성한다. 이것이 VDOM의 노드다.

FiberNode는 VDOM 트리를 구성하는 노드다.

FiberNode는 단순히 JSX 결과만 저장하지 않는다. VDOM 트리 구조, State, 생명주기(Lifecycle), 훅(Hook) 등 렌더링과 관련된 모든 정보를 담고 있다. 즉, FiberNode의 변화가 곧 렌더링 결과를 결정한다. 이제 FiberNode의 타입을 살펴보자

주의:

  • <Counter /> 같은 컴포넌트가 여러 번 렌더링되더라도, 각 렌더링마다 별도의 FiberNode가 생성된다.
  • FiberNode는 컴포넌트 안에 살아있는 것이 아니라, React에 살아있다.

FiberNode 타입으로 알아보자

interface FiberNode {
  // 컴포넌트에 대한 정보
  tag: WorkTag; // FunctionComponent, ClassComponent, HostComponent 등
  type: any; // 컴포넌트 함수 자체 (예: App)
  key: null | string; // reconciliation용 key
  elementType: any; // JSX element type

  // 트리 구조 (부모/자식/형제)
  return: FiberNode | null; // 부모
  child: FiberNode | null; // 첫 번째 자식
  sibling: FiberNode | null; // 형제

  // 렌더링 관련 상태
  pendingProps: any; // 아직 적용되지 않은 props
  memoizedProps: any; // 이전 렌더에서 사용된 props
  memoizedState: Hook | Effect | null; // Hook들의 연결 리스트 진입점
  flags: Flags; // commit 단계에서 수행할 작업들 (Placement, Update 등)

  // 스케줄링 및 성능 관리
  lanes: Lanes; // 업데이트 우선순위
  alternate: FiberNode | null; // current <-> workInProgress 쌍
}

여기서 State와 관련있는 프로퍼티memoizedState다.
memoizedState의 타입은 Hook 타입에 대해 먼저 알아보자

Hook 타입은 뭘까? - useState

우리가 컴포넌트 내부에서 훅을 사용하면, 훅마다 하나의 FiberNode에서 사용될 Hook 객체가 생성된다.

Hook의 순서가 중요한 이유

memoizedState : Hook 만 보면, 마치 우리가 컴포넌트엔 훅 하나만 할당할 수 있을 것 처럼 보인다.

interface Hook {
  baseQueue: Update<any> | null; // 이전에 처리하지 못한 상태 업데이트
  queue: Update<any> | null; // 현재 처리할 상태 업데이트

  baseState: any; // 렌더링때 쓰인 상태값
  memoizedState: any; // 업데이트를 적용하는 도중의 최신 상태값

  next: Hook | null; // 다음 Hook
}

하지만 next 라는 프로퍼티를 보면 알 수 있듯, Hook은 아래 타입과 같이 링크드리스트로 연결되어 있다. 그래서 컴포넌트마다 여러개의 훅을 사용할 수 있는 것이다.

React ReconcilerHooks LinkedList의 순서대로 Current FiberNode와 WorkInProgress FiberNode의 state값을 비교하기 때문에 갑자기 순서가 바뀌면 상태 비교를 제대로 할 수 없다. 그렇기 때문에, 우리가 조건부로 훅을 못쓰는 것이다.


여기서 queue를 주목하자, 보통 블로그나 유튜브에서 나오는 UpdateQueue를 말한다.

Update 타입은 뭘까? - setState

setState를 호출할 때마다, Update 객체가 생성된다.

Hook 타입과 동일한 next라는 프로퍼티를 보면 알 수 있듯 링크드리스트로 되어있다. 우리는 하나의 핸들러에서 setState를 여러번 호출할 수 있는데, 호출할 때마다 FiberNode.Hook.queue에 링크드리스트로 쌓인다.

interface Update {
  action: any; // setState의 인자
  next: Update | null; // 다음 Update
  eagerReducer; // 불필요한 리렌더 방지
  eagerState; // 불필요한 리렌더 방지
}

action 프로퍼티는 setState의 인자가 담기는데, 여기엔 어떤 값이라도 들어올 수 있다. 그리고 함수가 들어온다면 Updator Function이라고 부른다. 이 UpdatorFunction은 “재조정”과정에서 일반적인 원시값이나 객체와는 다르게 동작하는데, 그건 렌더링 프로세스 설명할때 하겠다.

!! 상태 업데이트가 Batch라고 말하는 이유

이렇게 setState를 Update 객체의 링크드리스트로 연결해뒀다가 재조정 과정에서 한꺼번에 계산되기 때문이다.

!! 상태 업데이트가 비동기라고 말하는 이유

setState(state + 1)이 호출될 때, 그 당시의 state를 들고 있기 때문이다. 즉 스냅샷으로 들고있고 Batch로 처리되기 때문에, 여러번 호출한다 할지라도 바로바로 적용이 안된다.

다만 Updator Function은 재조정과정에서 직전의 상태값을 그대로 인자로 받기때문에 동기적으로 동작한다.

Effect 타입은 뭘까 ? - useEffect

useEffect나 useLayoutEffect 같은 부수효과 훅들은 Hook 객체 대신 Effect 객체를 생성한다.

FiberNode.memoizedState에 Hook과 Effect들이 같이 링크드리스트로 연결된다.

interface Effect {
  tag: HookFlags; // HookLayout | HookPassive | HookHasEffect
  create: () => void; // effect callback
  destroy: (() => void) | null;
  deps: any[] | null; // ✅ 의존성 배열 저장됨
  next: Effect | null; // circular linked list
}

deps에는 의존성배열로 사용하고 있는 상태들의 당시의 스냅샷이 들어가게 되고 재조정(Render Phase)중 diff 단계에서 Current의 deps 값과 WorkInProgress의 deps값을 얕은 비교한다.

이때 deps가 다르다면 tag 플래그가 세워진다. 없다면 부수효과를 실행하지 않는다.

  • useLayoutEffect

    HooksLayout이라는 플래그로 업데이트 되고 Commit Phase의 layout phase가 끝나면 동기적으로 실행된다.

  • useEffect

    HooksPassive라는 플래그로 업데이트 되고 브라우저 Paint 단계가 끝나면 비동기로 실행된다.

!! 부수효과 함수는 왜 비동기가 안될까 ?

useEffect(async () => {}, []); // => 이거 안됨

Effectcreate가 부수효과 함수를 담는 곳이다,
단순하게 말하자면, 동기함수만 받도록 타입이 정의 되어있기 때문이다.

왜 동기함수만 받도록 설계되었을까?

React는 effect의 반환 값을 항상 cleanup 함수라고 가정한다.

React의 Commit Phase에서 useEffect와 useLayoutEffect가 처리되는데,
useEffect나 useLayoutEffect 모두, cleanup → create 호출 순서를 갖는다.
만약 Promise를 반환하게 되면, 아래와 같이 진행될 것이다.

cleanup 시작 --- (비동기라 아직 끝 안남)
create 실행
DOM 변경
paint
cleanup 비동기 resolve (타이밍 뒤늦게 도착)

이렇게 되면
Commit Phase는 “UI를 실제 DOM에 반영하고, 그에 따른 부수효과를 정리하고 다시 시작하는 과정” 인데, 이미 새로운 렌더가 반영됐는데, 이전 effect의 cleanup이 나중에 튀어나와서 실행될 수 있다.

UI의 레이스 컨디션을 만들어 낼 수 있게 되므로, 비동기 함수를 부수효과 함수로 만들 수 없게 설계되었다.


실전 : React의 렌더링 프로세스

이제 드디어, 실제 렌더링 과정을 알아보자

리액트에서는 렌더링 과정을 Render Phase, Commit Phase로 구분하고 있다. 나는 어떻게 렌더링이 트리거 되느냐부터 시작하려고한다.

0. Trigger

초기 렌더링이나 setState가 호출되면, React는

  1. React는 Update 객체를 생성해, FiberNode.Hook.queue에 연결한다.
    • 이때 Update.action에는 setState의 인자로 전달한 값이나 함수가 담긴다.
    • 여러번 setState가 호출되면, 링크드리스트로 계속 붙는다.
  2. React Reconciler는 곧바로 Work(말그대로 작업)라는 객체를 생성하고 React Scheduler에게 전달하여 작업을 언제할지 결정해달라고 요청한다. - 이때, Work는 FiberNode를 확장한 것으로, FiberNode의 lane 프로퍼티에 우선순위를 담는다.

이 과정을 거치면, 이제 렌더링 프로세스가 시작된다.

1. Render Phase (Reconciliation)

React Scheduler가 우선순위나 브라우저 유휴시간에 따라 작업(Work)을 Reconciler에게 할당하면서 Render Phase가 시작된다.

Render Phase는 React Reconciler가 담당하는 VDOM의 재조정(Reconciliation) 단계다. 재조정이란 FiberNode를 추가, 수정, 삭제하면서 업데이트가 필요한 부분을 계산하는 과정이다.

Render Phase가 시작되면서, 새로운 WorkInProgress VDOM이 생성되기 시작한다.

Render Phase는 아래와 같은 단계로 시작된다.

  1. 스케쥴러가 작업을 시작할 FiberNode의 위치를 알려주고 WorkInProgress Tree가 복제되며 생성된다. - 각 FiberNode들은 Current Tree의 Node와 alternate로 참조하게 된다.
  2. 스케쥴러가 알려준 위치부터 FiberNode의 memoizedState(Hook 링크드리스트)를 순회를 시작
  3. Hook 객체의 queue에 담긴 Update객체를 기반으로 State의 최종값을 계산해 나간다.
    • 이때, setState에 함수를 넘겼다면, 이때 그 함수(Updator Function)이 실행된다.
    • 계산할때마다 최신값을 Hooks.memoizedState에 갱신한다.
  4. Diff를 시작한다.
    • 타입(태그) → Key(존재한다면) → props/state 순서로 비교해서 달라진 점을 파악한다.
    • 달라진점이 있다면 WIP FiberNode에 작업 플래그(Update, Placement)를 넣는다.
    • 이때, memoizedState에 연결되어있던 Effect들의 deps도 비교되어 플래그가 갱신된다.
  5. 이렇게 WorkInProgress Fiber Tree가 완성된다.
    • Diff과정의 플래그 정보를 가지고 Commit Phase가 시작된다.

Key에 index를 넣으면 안되는 이유

  • VDOM은 링크드리스트로 Tree가 구현되어 있다.
  • 자식들을 렌더링 순서대로 Diffing 한다.
  • 달라짐의 여부에는 key도 존재하는데, 중간이나 앞이 삭제되는 경우 index를 key로 쓰면 Current와 WIP Node 비교대상이 꼬이게 되어, 엉뚱한 State가 유지되거나, 삭제될 수 있다.

최종 상태 값이 같을 때, 리렌더링 안되는 이유

상태 값이 같으면, 작업 Flag가 설정되지 않기 때문이다. 대신 updator Function의 실행은 재조정과정에서 상태값 계산에 들어가므로 항상 실행되는 것

/*
 * update function은 호출되지만
 * 기존 0과 값이 같아 Rerender는 콘솔에 출력되지 않는다.
 */
function Counter() {
  const [number, setNumber] = useState(0);
  console.log('Rerender');

  const handleClick = () => {
    setNumber(() => {
      console.log('update function!!');
      return 0;
    });
  };

  return (
    <>
      <h1>{number}</h1>
      <button onClick={handleClick}>Increase the number</button>
    </>
  );
}

2. Commit Phase

React Renderer가 실제로 DOM, refs, lifecycle effect 등을 브라우저에 반영하는 단계
React는 Commit Phase를 세 단계(sub-phase)로 나눠서 처리한다.

1. DOM 업데이트

실제 DOM에 적용하는 단계로

Render Phase에서 FiberNode.flags에 삽입된 상태를 참고하여 어떤 작업을 수행할지 결정하고 DOM 적용

  • Placement → 새 DOM 삽입 명령
  • Update → DOM 속성/텍스트 업데이트
  • Deletion → DOM 제거 명령

2. Ref를 DOM에 연결해주거나 메모하는 단계

DOM 변경 전 수행되는 단계로, ref 값을 메모하는 단계가 수행된다. ref가 DOM이라면 이때 매칭시켜줌 여튼 커밋단계에서 ref가 결정된다.

3. Layout Phase (Layout Effects Phase)

DOM 변경 후 수행되는 단계로 FiberNode의 Effect들의 플래그를 보고 useLayoutEffect의 부수효과이며 실행 플래그가 세워져 있으면, 이때 동기적으로 실행된다.

물론 cleanup 함수를 먼저 실행한 후에 effect를 실행한다.

그리고나서 브라우저의 Layout/Reflow를 수행한다.

4. useEffect는 브라우저의 paint 단계가 끝나고 수행된다.

FiberNode의 Effect들의 플래그를 보고 usetEffect의 부수효과이며 실행 플래그가 세워져 있으면, 이때 비동기로 실행된다.

여기서도 물론 cleanup 함수를 먼저 실행한 후에 effect를 실행한다.

React 스케쥴러나, React 렌더러는 어떻게 브라우저 유휴시간이나 Paint가 끝났다고 알 수 있을까?

  • requestIdleCallback 이라는 API가 있다. 한 프레임에서 브라우저가 남은 유휴시간을 알려주는데, React는 여기서 남는시간을 사용한다.
  • Paint 끝나는 것을 감지하지는 않는다. 그저 브라우저의 MacroTaskQueue에 자기 작업을 집어넣으면서 실행되는거임

References